windborne 1.1.1__py3-none-any.whl → 1.1.3__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.
- windborne/__init__.py +2 -0
- windborne/api_request.py +1 -2
- windborne/cli.py +30 -28
- windborne/data_api.py +61 -26
- windborne/forecasts_api.py +48 -47
- windborne/{cyclone_formatting.py → track_formatting.py} +75 -23
- windborne/utils.py +34 -5
- {windborne-1.1.1.dist-info → windborne-1.1.3.dist-info}/METADATA +1 -1
- windborne-1.1.3.dist-info/RECORD +13 -0
- windborne-1.1.1.dist-info/RECORD +0 -13
- {windborne-1.1.1.dist-info → windborne-1.1.3.dist-info}/WHEEL +0 -0
- {windborne-1.1.1.dist-info → windborne-1.1.3.dist-info}/entry_points.txt +0 -0
- {windborne-1.1.1.dist-info → windborne-1.1.3.dist-info}/top_level.txt +0 -0
windborne/__init__.py
CHANGED
@@ -14,6 +14,7 @@ from .data_api import (
|
|
14
14
|
get_flying_missions,
|
15
15
|
get_mission_launch_site,
|
16
16
|
get_predicted_path,
|
17
|
+
get_flight_path
|
17
18
|
)
|
18
19
|
|
19
20
|
# Import Forecasts API functions
|
@@ -50,6 +51,7 @@ __all__ = [
|
|
50
51
|
"get_flying_missions",
|
51
52
|
"get_mission_launch_site",
|
52
53
|
"get_predicted_path",
|
54
|
+
"get_flight_path",
|
53
55
|
|
54
56
|
"get_point_forecasts",
|
55
57
|
"get_initialization_times",
|
windborne/api_request.py
CHANGED
@@ -199,8 +199,7 @@ def make_api_request(url, params=None, as_json=True, retry_counter=0):
|
|
199
199
|
mission_id = url.split('/missions/')[1].split('/')[0]
|
200
200
|
print(f"Mission ID provided: {mission_id}")
|
201
201
|
print(f"No mission found with id: {mission_id}")
|
202
|
-
|
203
|
-
|
202
|
+
return None
|
204
203
|
elif http_err.response.status_code == 502:
|
205
204
|
print(f"Temporary connection failure; sleeping for {2**retry_counter}s before retrying")
|
206
205
|
print(f"Underlying error: 502 Bad Gateway")
|
windborne/cli.py
CHANGED
@@ -14,6 +14,7 @@ from . import (
|
|
14
14
|
get_flying_missions,
|
15
15
|
get_mission_launch_site,
|
16
16
|
get_predicted_path,
|
17
|
+
get_flight_path,
|
17
18
|
|
18
19
|
get_point_forecasts,
|
19
20
|
get_initialization_times,
|
@@ -125,6 +126,11 @@ def main():
|
|
125
126
|
prediction_parser.add_argument('mission_id', help='Mission ID')
|
126
127
|
prediction_parser.add_argument('output', nargs='?', help='Output file')
|
127
128
|
|
129
|
+
# Get Flight Path Command
|
130
|
+
flight_path_parser = subparsers.add_parser('flight-path', help='Get traveled flight path')
|
131
|
+
flight_path_parser.add_argument('mission_id', help='Mission ID')
|
132
|
+
flight_path_parser.add_argument('output', nargs='?', help='Output file')
|
133
|
+
|
128
134
|
####################################################################################################################
|
129
135
|
# FORECASTS API FUNCTIONS
|
130
136
|
####################################################################################################################
|
@@ -139,7 +145,7 @@ def main():
|
|
139
145
|
points_parser.add_argument('-mh','--min-hour', type=int, help='Minimum forecast hour')
|
140
146
|
points_parser.add_argument('-xh','--max-hour', type=int, help='Maximum forecast hour')
|
141
147
|
points_parser.add_argument('-i', '--init-time', help='Initialization time')
|
142
|
-
points_parser.add_argument('output_file', help='Output file')
|
148
|
+
points_parser.add_argument('output_file', nargs='?', help='Output file')
|
143
149
|
|
144
150
|
# GRIDDED FORECASTS
|
145
151
|
####################################################################################################################
|
@@ -210,9 +216,9 @@ def main():
|
|
210
216
|
####################################################################################################################
|
211
217
|
|
212
218
|
# Tropical Cyclones Command
|
213
|
-
|
214
|
-
|
215
|
-
|
219
|
+
tropical_cyclones_parser = subparsers.add_parser('tropical_cyclones', help='Get tropical cyclone forecasts')
|
220
|
+
tropical_cyclones_parser.add_argument('-b', '--basin', help='Optional: filter tropical cyclones on basin[ NA, EP, WP, NI, SI, AU, SP]')
|
221
|
+
tropical_cyclones_parser.add_argument('args', nargs='*',
|
216
222
|
help='[optional: initialization time (YYYYMMDDHH, YYYY-MM-DDTHH, or YYYY-MM-DDTHH:mm:ss)] output_file')
|
217
223
|
|
218
224
|
# Initialization Times Command
|
@@ -377,8 +383,18 @@ def main():
|
|
377
383
|
elif args.command == 'predict-path':
|
378
384
|
get_predicted_path(
|
379
385
|
mission_id=args.mission_id,
|
380
|
-
output_file=args.output
|
386
|
+
output_file=args.output,
|
387
|
+
print_result=(not args.output)
|
381
388
|
)
|
389
|
+
|
390
|
+
elif args.command == 'flight-path':
|
391
|
+
get_flight_path(
|
392
|
+
mission_id=args.mission_id,
|
393
|
+
output_file=args.output,
|
394
|
+
print_result=(not args.output)
|
395
|
+
)
|
396
|
+
|
397
|
+
|
382
398
|
####################################################################################################################
|
383
399
|
# FORECASTS API FUNCTIONS CALLED
|
384
400
|
####################################################################################################################
|
@@ -396,15 +412,12 @@ def main():
|
|
396
412
|
min_forecast_hour=min_forecast_hour,
|
397
413
|
max_forecast_hour=max_forecast_hour,
|
398
414
|
initialization_time=initialization_time,
|
399
|
-
output_file=args.output_file
|
415
|
+
output_file=args.output_file,
|
416
|
+
print_response=(not args.output_file)
|
400
417
|
)
|
401
418
|
|
402
419
|
elif args.command == 'init_times':
|
403
|
-
|
404
|
-
print("Available initialization times for point forecasts:\n")
|
405
|
-
pprint(get_initialization_times())
|
406
|
-
else:
|
407
|
-
print("We can't currently display available initialization times for point forecasts:\n")
|
420
|
+
get_initialization_times(print_response=True)
|
408
421
|
|
409
422
|
elif args.command == 'grid_temp_2m':
|
410
423
|
# Parse grid_temp_2m arguments
|
@@ -589,39 +602,28 @@ def main():
|
|
589
602
|
print("Too many arguments")
|
590
603
|
print("\nUsage: windborne hist_500hpa_wind_v initialization_time forecast_hour output_file")
|
591
604
|
|
592
|
-
elif args.command == '
|
605
|
+
elif args.command == 'tropical_cyclones':
|
593
606
|
# Parse cyclones arguments
|
594
|
-
basin_name = '
|
607
|
+
basin_name = 'all basins'
|
595
608
|
if args.basin:
|
596
609
|
basin_name = f"{args.basin} basin"
|
597
|
-
print(f"Checking for tropical cyclones only within {args.basin} basin\n")
|
598
610
|
|
599
611
|
if len(args.args) == 0:
|
600
|
-
|
601
|
-
|
602
|
-
print(f"Found {len(get_tropical_cyclones())} cyclone(s)\n")
|
603
|
-
pprint(get_tropical_cyclones(basin=args.basin))
|
604
|
-
return
|
605
|
-
else:
|
606
|
-
print("There are no active tropical cyclones for our latest available initialization time.")
|
612
|
+
get_tropical_cyclones(basin=args.basin, print_response=True)
|
613
|
+
return
|
607
614
|
elif len(args.args) == 1:
|
608
615
|
if '.' in args.args[0]:
|
609
616
|
# Save tcs with the latest available initialization time in filename
|
610
617
|
get_tropical_cyclones(basin=args.basin, output_file=args.args[0])
|
611
618
|
else:
|
612
619
|
# Display tcs for selected initialization time
|
613
|
-
|
614
|
-
print(f"Loading tropical cyclones for initialization time {args.args[0]}\n")
|
615
|
-
print(f"Found {len(get_tropical_cyclones(initialization_time=args.args[0]))} cyclone(s)\n")
|
616
|
-
pprint(get_tropical_cyclones(initialization_time=args.args[0], basin=args.basin))
|
617
|
-
else:
|
618
|
-
print(f"No active tropical cyclones for {basin_name} and {args.args[0]} initialization time.")
|
620
|
+
get_tropical_cyclones(initialization_time=args.args[0], basin=args.basin, print_response=True)
|
619
621
|
elif len(args.args) == 2:
|
620
622
|
print(f"Saving tropical cyclones for initialization time {args.args[0]} and {basin_name}\n")
|
621
623
|
get_tropical_cyclones(initialization_time=args.args[0], basin=args.basin, output_file=args.args[1])
|
622
624
|
else:
|
623
625
|
print("Error: Too many arguments")
|
624
|
-
print("Usage: windborne
|
626
|
+
print("Usage: windborne tropical_cyclones [initialization_time] output_file")
|
625
627
|
|
626
628
|
else:
|
627
629
|
parser.print_help()
|
windborne/data_api.py
CHANGED
@@ -6,7 +6,8 @@ import json
|
|
6
6
|
|
7
7
|
from .api_request import make_api_request
|
8
8
|
from .observation_formatting import format_little_r, convert_to_netcdf
|
9
|
-
from .utils import to_unix_timestamp, save_arbitrary_response
|
9
|
+
from .utils import to_unix_timestamp, save_arbitrary_response, print_table
|
10
|
+
from .track_formatting import save_track
|
10
11
|
|
11
12
|
DATA_API_BASE_URL = "https://sensor-data.windbornesystems.com/api/v1"
|
12
13
|
|
@@ -551,6 +552,9 @@ def get_flying_missions(output_file=None, print_results=False):
|
|
551
552
|
url = f"{DATA_API_BASE_URL}/missions.json"
|
552
553
|
flying_missions_response = make_api_request(url)
|
553
554
|
flying_missions = flying_missions_response.get("missions", [])
|
555
|
+
for mission in flying_missions:
|
556
|
+
if mission.get('number'):
|
557
|
+
mission['name'] = f"W-{mission['number']}"
|
554
558
|
|
555
559
|
# Display currently flying missions only if we are in cli and we don't save info in file
|
556
560
|
if print_results:
|
@@ -617,7 +621,8 @@ def get_mission_launch_site(mission_id=None, output_file=None, print_result=Fals
|
|
617
621
|
|
618
622
|
return response.get('launch_site')
|
619
623
|
|
620
|
-
|
624
|
+
|
625
|
+
def get_predicted_path(mission_id=None, output_file=None, print_result=False):
|
621
626
|
"""
|
622
627
|
Fetches the predicted flight path for a given mission.
|
623
628
|
Displays currently flying missions if the provided mission ID is invalid.
|
@@ -625,7 +630,7 @@ def get_predicted_path(mission_id=None, output_file=None):
|
|
625
630
|
Args:
|
626
631
|
mission_id (str): The ID of the mission to fetch the prediction for.
|
627
632
|
output_file (str): Optional path to save the response data.
|
628
|
-
|
633
|
+
print_result (bool): Whether to print the results in the CLI.
|
629
634
|
|
630
635
|
Returns:
|
631
636
|
list: The API response containing the predicted flight path data.
|
@@ -635,40 +640,70 @@ def get_predicted_path(mission_id=None, output_file=None):
|
|
635
640
|
return
|
636
641
|
|
637
642
|
# Check if provided mission ID belong to a flying mission
|
638
|
-
|
639
|
-
|
643
|
+
flying_missions = get_flying_missions()
|
644
|
+
|
645
|
+
mission = None
|
646
|
+
for candidate in flying_missions:
|
647
|
+
if candidate.get('id') == mission_id or candidate.get('name') == mission_id:
|
648
|
+
mission = candidate
|
649
|
+
break
|
640
650
|
|
641
|
-
if
|
651
|
+
if mission is None:
|
642
652
|
print(f"Provided mission ID '{mission_id}' does not belong to a mission that is currently flying.")
|
643
653
|
|
644
654
|
# Display currently flying missions
|
645
655
|
if flying_missions:
|
646
656
|
print("\nCurrently flying missions:\n")
|
647
657
|
|
648
|
-
|
649
|
-
headers = ["Index", "Mission ID", "Mission Name"]
|
650
|
-
rows = [
|
651
|
-
[str(i), mission.get("id", "N/A"), mission.get("name", "Unnamed Mission")]
|
652
|
-
for i, mission in enumerate(flying_missions, start=1)
|
653
|
-
]
|
654
|
-
|
655
|
-
# Kinda overkill | but it's a good practice if we ever change missions naming convention
|
656
|
-
# Calculate column widths
|
657
|
-
col_widths = [max(len(cell) for cell in col) + 2 for col in zip(headers, *rows)]
|
658
|
-
|
659
|
-
# Display table
|
660
|
-
print("".join(f"{headers[i]:<{col_widths[i]}}" for i in range(len(headers))))
|
661
|
-
print("".join("-" * col_width for col_width in col_widths))
|
662
|
-
for row in rows:
|
663
|
-
print("".join(f"{row[i]:<{col_widths[i]}}" for i in range(len(row))))
|
658
|
+
print_table(flying_missions, keys=['id', 'name'], headers=['Mission ID', 'Mission Name'])
|
664
659
|
else:
|
665
660
|
print("No missions are currently flying.")
|
666
661
|
return
|
667
662
|
|
668
|
-
url = f"{DATA_API_BASE_URL}/missions/{
|
663
|
+
url = f"{DATA_API_BASE_URL}/missions/{mission.get('id')}/prediction.json"
|
669
664
|
response = make_api_request(url)
|
670
665
|
|
666
|
+
if response is None:
|
667
|
+
return
|
668
|
+
|
671
669
|
if output_file:
|
672
|
-
|
673
|
-
|
674
|
-
|
670
|
+
name = mission.get('name', mission_id)
|
671
|
+
save_track(output_file, {name: response['prediction']}, time_key='time')
|
672
|
+
|
673
|
+
if print_result:
|
674
|
+
print("Predicted flight path\n")
|
675
|
+
print_table(response['prediction'], keys=['time', 'latitude', 'longitude', 'altitude'], headers=['Time', 'Latitude', 'Longitude', 'Altitude'])
|
676
|
+
|
677
|
+
return response.get('prediction')
|
678
|
+
|
679
|
+
|
680
|
+
def get_flight_path(mission_id=None, output_file=None, print_result=False):
|
681
|
+
"""
|
682
|
+
Fetches the flight path for a given mission.
|
683
|
+
|
684
|
+
Args:
|
685
|
+
mission_id (str): The ID of the mission to fetch the flight path for.
|
686
|
+
output_file (str): Optional path to save the response data.
|
687
|
+
print_result (bool): Whether to print the results in the CLI.
|
688
|
+
|
689
|
+
Returns:
|
690
|
+
list: The API response containing the flight path.
|
691
|
+
"""
|
692
|
+
if not mission_id:
|
693
|
+
print("A mission id is required to get a flight path")
|
694
|
+
return
|
695
|
+
|
696
|
+
url = f"{DATA_API_BASE_URL}/missions/{mission_id}/flight_data.json"
|
697
|
+
response = make_api_request(url)
|
698
|
+
|
699
|
+
if response is None:
|
700
|
+
return
|
701
|
+
|
702
|
+
if output_file:
|
703
|
+
save_track(output_file, {mission_id: response['flight_data']}, time_key='transmit_time')
|
704
|
+
|
705
|
+
if print_result:
|
706
|
+
print("Flight path\n")
|
707
|
+
print_table(response['flight_data'], keys=['transmit_time', 'latitude', 'longitude', 'altitude'], headers=['Time', 'Latitude', 'Longitude', 'Altitude'])
|
708
|
+
|
709
|
+
return response.get('flight_data')
|
windborne/forecasts_api.py
CHANGED
@@ -2,29 +2,22 @@ import requests
|
|
2
2
|
|
3
3
|
from .utils import (
|
4
4
|
parse_time,
|
5
|
-
save_arbitrary_response
|
5
|
+
save_arbitrary_response,
|
6
|
+
print_table
|
6
7
|
)
|
7
8
|
|
8
|
-
from .api_request import
|
9
|
-
|
10
|
-
)
|
11
|
-
|
12
|
-
from .cyclone_formatting import (
|
13
|
-
save_track_as_geojson,
|
14
|
-
save_track_as_gpx,
|
15
|
-
save_track_as_kml,
|
16
|
-
save_track_as_little_r
|
17
|
-
)
|
9
|
+
from .api_request import make_api_request
|
10
|
+
from .track_formatting import save_track
|
18
11
|
|
19
12
|
FORECASTS_API_BASE_URL = "https://forecasts.windbornesystems.com/api/v1"
|
20
13
|
FORECASTS_GRIDDED_URL = f"{FORECASTS_API_BASE_URL}/gridded"
|
21
14
|
FORECASTS_HISTORICAL_URL = f"{FORECASTS_API_BASE_URL}/gridded/historical"
|
22
15
|
FORECASTS_TCS_URL = f"{FORECASTS_API_BASE_URL}/tropical_cyclones"
|
23
|
-
TCS_SUPPORTED_FORMATS = ('.csv', '.json', '.geojson', '.gpx', '.kml', 'little_r')
|
16
|
+
TCS_SUPPORTED_FORMATS = ('.csv', '.json', '.geojson', '.gpx', '.kml', '.little_r')
|
24
17
|
|
25
18
|
|
26
19
|
# Point forecasts
|
27
|
-
def get_point_forecasts(coordinates, min_forecast_time=None, max_forecast_time=None, min_forecast_hour=None, max_forecast_hour=None, initialization_time=None, output_file=None):
|
20
|
+
def get_point_forecasts(coordinates, min_forecast_time=None, max_forecast_time=None, min_forecast_hour=None, max_forecast_hour=None, initialization_time=None, output_file=None, print_response=False):
|
28
21
|
"""
|
29
22
|
Get point forecasts from the API.
|
30
23
|
|
@@ -38,6 +31,7 @@ def get_point_forecasts(coordinates, min_forecast_time=None, max_forecast_time=N
|
|
38
31
|
initialization_time (str, optional): Initialization time in ISO 8601 format (YYYY-MM-DDTHH:00:00)
|
39
32
|
output_file (str, optional): Path to save the response data
|
40
33
|
Supported formats: .json, .csv
|
34
|
+
print_response (bool, optional): Whether to print the response data
|
41
35
|
"""
|
42
36
|
|
43
37
|
# coordinates should be formatted as a semi-colon separated list of latitude,longitude tuples, eg 37,-121;40.3,-100
|
@@ -91,13 +85,25 @@ def get_point_forecasts(coordinates, min_forecast_time=None, max_forecast_time=N
|
|
91
85
|
initialization_time = parse_time(initialization_time,init_time_flag=True)
|
92
86
|
params["initialization_time"] = initialization_time
|
93
87
|
|
94
|
-
|
88
|
+
if print_response:
|
89
|
+
print("Generating point forecast...")
|
95
90
|
|
96
91
|
response = make_api_request(f"{FORECASTS_API_BASE_URL}/points", params=params)
|
97
92
|
|
98
93
|
if output_file:
|
99
94
|
save_arbitrary_response(output_file, response, csv_data_key='forecasts')
|
100
95
|
|
96
|
+
if print_response:
|
97
|
+
unformatted_coordinates = formatted_coordinates.split(';')
|
98
|
+
|
99
|
+
keys = ['time', 'temperature_2m', 'dewpoint_2m', 'wind_u_10m', 'wind_v_10m', 'precipitation', 'pressure_msl']
|
100
|
+
headers = ['Time', '2m Temperature (°C)', '2m Dewpoint (°C)', 'Wind U (m/s)', 'Wind V (m/s)', 'Precipitation (mm)', 'MSL Pressure (hPa)']
|
101
|
+
|
102
|
+
for i in range(len(response['forecasts'])):
|
103
|
+
latitude, longitude = unformatted_coordinates[i].split(',')
|
104
|
+
print(f"\nForecast for ({latitude}, {longitude})")
|
105
|
+
print_table(response['forecasts'][i], keys=keys, headers=headers)
|
106
|
+
|
101
107
|
return response
|
102
108
|
|
103
109
|
|
@@ -126,6 +132,9 @@ def get_gridded_forecast(time, variable, output_file=None):
|
|
126
132
|
|
127
133
|
response = make_api_request(f"{FORECASTS_GRIDDED_URL}/{variable}", params=params, as_json=False)
|
128
134
|
|
135
|
+
if response is None:
|
136
|
+
return None
|
137
|
+
|
129
138
|
if output_file:
|
130
139
|
print(f"Output URL found; downloading to {output_file}...")
|
131
140
|
download_and_save_output(output_file, response)
|
@@ -181,6 +190,9 @@ def get_historical_output(initialization_time, forecast_hour, variable, output_f
|
|
181
190
|
|
182
191
|
response = make_api_request(f"{FORECASTS_HISTORICAL_URL}/{variable}", params=params, as_json=False)
|
183
192
|
|
193
|
+
if response is None:
|
194
|
+
return None
|
195
|
+
|
184
196
|
if output_file:
|
185
197
|
print(f"Output URL found; downloading to {output_file}...")
|
186
198
|
download_and_save_output(output_file, response)
|
@@ -201,7 +213,7 @@ def get_historical_500hpa_wind_v(initialization_time, forecast_hour, output_file
|
|
201
213
|
return get_historical_output(initialization_time, forecast_hour, "500/wind_v", output_file)
|
202
214
|
|
203
215
|
|
204
|
-
def get_tropical_cyclones(initialization_time=None, basin=None, output_file=None):
|
216
|
+
def get_tropical_cyclones(initialization_time=None, basin=None, output_file=None, print_response=False):
|
205
217
|
"""
|
206
218
|
Get tropical cyclone data from the API.
|
207
219
|
|
@@ -209,8 +221,10 @@ def get_tropical_cyclones(initialization_time=None, basin=None, output_file=None
|
|
209
221
|
initialization_time (str): Date in either ISO 8601 format (YYYY-MM-DDTHH:00:00)
|
210
222
|
or compact format (YYYYMMDDHH)
|
211
223
|
where HH must be 00, 06, 12, or 18
|
224
|
+
basin (str, optional): Basin code (e.g., 'NA', 'EP', 'WP', 'NI', 'SI', 'AU', 'SP')
|
212
225
|
output_file (str, optional): Path to save the response data
|
213
226
|
Supported formats: .json, .csv, .gpx, .geojson, .kml, .little_r
|
227
|
+
print_response (bool, optional): Whether to print the response data
|
214
228
|
|
215
229
|
Returns:
|
216
230
|
dict: API response data or None if there's an error
|
@@ -221,7 +235,6 @@ def get_tropical_cyclones(initialization_time=None, basin=None, output_file=None
|
|
221
235
|
initialization_time_parsed = parse_time(initialization_time, init_time_flag=True)
|
222
236
|
params["initialization_time"] = initialization_time_parsed
|
223
237
|
else:
|
224
|
-
# Madee this for our displaying message when no active tcs found
|
225
238
|
initialization_time = 'latest'
|
226
239
|
|
227
240
|
if basin:
|
@@ -241,11 +254,7 @@ def get_tropical_cyclones(initialization_time=None, basin=None, output_file=None
|
|
241
254
|
response = make_api_request(FORECASTS_TCS_URL, params=params)
|
242
255
|
|
243
256
|
if output_file:
|
244
|
-
if
|
245
|
-
print("You have to provide a filetype for your output file.")
|
246
|
-
print_tc_supported_formats()
|
247
|
-
exit (4)
|
248
|
-
elif not output_file.lower().endswith(TCS_SUPPORTED_FORMATS):
|
257
|
+
if not output_file.lower().endswith(TCS_SUPPORTED_FORMATS):
|
249
258
|
print("Unsupported file format.")
|
250
259
|
print_tc_supported_formats()
|
251
260
|
exit(44)
|
@@ -254,42 +263,28 @@ def get_tropical_cyclones(initialization_time=None, basin=None, output_file=None
|
|
254
263
|
# make_api_request covers 403, 404, 502, HTTP, Connections Errors
|
255
264
|
# If we pass all of these and we get an empty dictionary ==> there are no active TCs
|
256
265
|
print("There are no active tropical cyclones for your request\n")
|
257
|
-
print("We didn't save any file on your machine.")
|
258
266
|
# It's pointless to save an empty file
|
259
267
|
# save_response_to_file() will throw error on saving {}
|
260
268
|
elif response is None:
|
261
269
|
print("-------------------------------------------------------")
|
262
270
|
print("You are too quick!\nThe tropical cyclone data for initialization time are not uploaded yet.")
|
263
|
-
print('You may check again in a few
|
264
|
-
|
265
|
-
|
266
|
-
|
271
|
+
print('You may check again in a few hour.')
|
272
|
+
else:
|
273
|
+
save_track(output_file, response, require_ids=True)
|
274
|
+
|
275
|
+
if print_response:
|
276
|
+
if len(response) == 0:
|
277
|
+
print("No tropical cyclones for initialization time:", initialization_time)
|
278
|
+
else:
|
279
|
+
print("Tropical Cyclones for initialization time:", initialization_time)
|
267
280
|
for cyclone_id, tracks in response.items():
|
268
|
-
|
269
|
-
|
270
|
-
'cyclone_id': cyclone_id,
|
271
|
-
'latitude': track['latitude'],
|
272
|
-
'longitude': track['longitude'],
|
273
|
-
'time': track['time']
|
274
|
-
}
|
275
|
-
flattened_data.append(track_data)
|
276
|
-
save_arbitrary_response(output_file, {'prediction': flattened_data}, csv_data_key='prediction')
|
277
|
-
elif output_file.lower().endswith('.json'):
|
278
|
-
# Direct save for JSON
|
279
|
-
save_arbitrary_response(output_file, response)
|
280
|
-
elif output_file.lower().endswith('.geojson'):
|
281
|
-
save_track_as_geojson(output_file, response)
|
282
|
-
elif output_file.lower().endswith('.gpx'):
|
283
|
-
save_track_as_gpx(output_file, response)
|
284
|
-
elif output_file.lower().endswith('.kml'):
|
285
|
-
save_track_as_kml(output_file, response)
|
286
|
-
elif output_file.lower().endswith('.little_r'):
|
287
|
-
save_track_as_little_r(output_file, response)
|
281
|
+
print(f"\nCyclone ID: {cyclone_id}")
|
282
|
+
print_table(tracks, keys=['time', 'latitude', 'longitude'], headers=['Time', 'Latitude', 'Longitude'])
|
288
283
|
|
289
284
|
return response
|
290
285
|
|
291
286
|
|
292
|
-
def get_initialization_times():
|
287
|
+
def get_initialization_times(print_response=False):
|
293
288
|
"""
|
294
289
|
Get available WeatherMesh initialization times (also known as cycle times).
|
295
290
|
|
@@ -298,6 +293,12 @@ def get_initialization_times():
|
|
298
293
|
|
299
294
|
response = make_api_request(f"{FORECASTS_API_BASE_URL}/initialization_times.json")
|
300
295
|
|
296
|
+
if print_response:
|
297
|
+
print("Latest initialization time:", response['latest'])
|
298
|
+
print("Available initialization times:")
|
299
|
+
for time in response['available']:
|
300
|
+
print(f" - {time}")
|
301
|
+
|
301
302
|
return response
|
302
303
|
|
303
304
|
|
@@ -1,15 +1,69 @@
|
|
1
|
-
from datetime import datetime
|
1
|
+
from datetime import datetime
|
2
|
+
import json
|
2
3
|
|
4
|
+
TRACK_SUPPORTED_FORMATS = ['.csv', '.json', '.geojson', '.gpx', '.kml', 'little_r']
|
3
5
|
|
4
|
-
def
|
6
|
+
def save_track(output_file, track_data, time_key='time', require_ids=False):
|
5
7
|
"""
|
6
|
-
|
8
|
+
Save track data to a file in the specified format.
|
9
|
+
Expects track_data to be a dictionary with cyclone/mission IDs as keys and lists of track points as values.
|
7
10
|
"""
|
11
|
+
include_id = require_ids or len(track_data) > 1
|
12
|
+
|
13
|
+
if output_file.lower().endswith('.json'):
|
14
|
+
with open(output_file, 'w', encoding='utf-8') as f:
|
15
|
+
json.dump(track_data, f, indent=4)
|
16
|
+
elif output_file.lower().endswith('.csv'):
|
17
|
+
save_track_as_csv(output_file, track_data, time_key=time_key, include_id=include_id)
|
18
|
+
elif output_file.lower().endswith('.geojson'):
|
19
|
+
save_track_as_geojson(output_file, track_data, time_key=time_key)
|
20
|
+
elif output_file.lower().endswith('.gpx'):
|
21
|
+
save_track_as_gpx(output_file, track_data, time_key=time_key)
|
22
|
+
elif output_file.lower().endswith('.kml'):
|
23
|
+
save_track_as_kml(output_file, track_data)
|
24
|
+
elif output_file.lower().endswith('.little_r'):
|
25
|
+
save_track_as_little_r(output_file, track_data, time_key=time_key)
|
26
|
+
else:
|
27
|
+
print(f"Unsupported file format. Supported formats are: {', '.join(TRACK_SUPPORTED_FORMATS)}")
|
28
|
+
return
|
29
|
+
|
30
|
+
|
31
|
+
def save_track_as_csv(filename, track_data, time_key='time', include_id=False):
|
32
|
+
"""
|
33
|
+
Convert and save track data as CSV.
|
34
|
+
"""
|
35
|
+
flattened_data = []
|
36
|
+
for name, tracks in track_data.items():
|
37
|
+
for track in tracks:
|
38
|
+
track_data = {
|
39
|
+
'id': name,
|
40
|
+
'latitude': track['latitude'],
|
41
|
+
'longitude': track['longitude'],
|
42
|
+
'time': track[time_key]
|
43
|
+
}
|
44
|
+
flattened_data.append(track_data)
|
45
|
+
|
8
46
|
with open(filename, 'w', encoding='utf-8') as f:
|
9
|
-
|
47
|
+
if include_id:
|
48
|
+
f.write('id,latitude,longitude,time\n')
|
49
|
+
else:
|
50
|
+
f.write('latitude,longitude,time\n')
|
51
|
+
|
52
|
+
for row in flattened_data:
|
53
|
+
if include_id:
|
54
|
+
f.write(f"{row['id']},{row['latitude']},{row['longitude']},{row['time']}\n")
|
55
|
+
else:
|
56
|
+
f.write(f"{row['latitude']},{row['longitude']},{row['time']}\n")
|
57
|
+
|
58
|
+
def save_track_as_little_r(filename, track_data, time_key='time'):
|
59
|
+
"""
|
60
|
+
Convert and save track data in little_R format.
|
61
|
+
"""
|
62
|
+
with open(filename, 'w', encoding='utf-8') as f:
|
63
|
+
for cyclone_id, tracks in track_data.items():
|
10
64
|
for track in tracks:
|
11
65
|
# Parse the time
|
12
|
-
dt = datetime.fromisoformat(track[
|
66
|
+
dt = datetime.fromisoformat(track[time_key].replace('Z', '+00:00'))
|
13
67
|
|
14
68
|
# Header line 1
|
15
69
|
header1 = f"{float(track['latitude']):20.5f}{float(track['longitude']):20.5f}{'HMS':40}"
|
@@ -35,21 +89,20 @@ def save_track_as_little_r(filename, cyclone_data):
|
|
35
89
|
print("Saved to", filename)
|
36
90
|
|
37
91
|
|
38
|
-
def save_track_as_kml(filename,
|
92
|
+
def save_track_as_kml(filename, track_data):
|
39
93
|
"""
|
40
|
-
Convert and save
|
94
|
+
Convert and save track data as KML, handling meridian crossing.
|
41
95
|
"""
|
42
96
|
kml = '<?xml version="1.0" encoding="UTF-8"?>\n'
|
43
97
|
kml += '<kml xmlns="http://www.opengis.net/kml/2.2">\n<Document>\n'
|
44
98
|
|
45
|
-
for
|
46
|
-
kml += f' <Placemark>\n <name>{
|
99
|
+
for id, tracks in track_data.items():
|
100
|
+
kml += f' <Placemark>\n <name>{id}</name>\n <MultiGeometry>\n'
|
47
101
|
|
48
102
|
current_segment = []
|
49
103
|
|
50
104
|
for i in range(len(tracks)):
|
51
105
|
lon = float(tracks[i]['longitude'])
|
52
|
-
lat = float(tracks[i]['latitude'])
|
53
106
|
|
54
107
|
if not current_segment:
|
55
108
|
current_segment.append(tracks[i])
|
@@ -61,7 +114,7 @@ def save_track_as_kml(filename, cyclone_data):
|
|
61
114
|
if abs(lon - prev_lon) > 180:
|
62
115
|
# Write the current segment
|
63
116
|
kml += ' <LineString>\n <coordinates>\n'
|
64
|
-
coordinates = [f' {track["longitude"]},{track["latitude"]},{0}'
|
117
|
+
coordinates = [f' {track["longitude"]},{track["latitude"]},{track.get("altitude", 0)}'
|
65
118
|
for track in current_segment]
|
66
119
|
kml += '\n'.join(coordinates)
|
67
120
|
kml += '\n </coordinates>\n </LineString>\n'
|
@@ -74,7 +127,7 @@ def save_track_as_kml(filename, cyclone_data):
|
|
74
127
|
# Write the last segment if it's not empty
|
75
128
|
if current_segment:
|
76
129
|
kml += ' <LineString>\n <coordinates>\n'
|
77
|
-
coordinates = [f' {track["longitude"]},{track["latitude"]},{0}'
|
130
|
+
coordinates = [f' {track["longitude"]},{track["latitude"]},{track.get("altitude", 0)}'
|
78
131
|
for track in current_segment]
|
79
132
|
kml += '\n'.join(coordinates)
|
80
133
|
kml += '\n </coordinates>\n </LineString>\n'
|
@@ -88,12 +141,12 @@ def save_track_as_kml(filename, cyclone_data):
|
|
88
141
|
print(f"Saved to {filename}")
|
89
142
|
|
90
143
|
|
91
|
-
def save_track_as_gpx(filename,
|
92
|
-
"""Convert and save
|
144
|
+
def save_track_as_gpx(filename, track_data, time_key='time'):
|
145
|
+
"""Convert and save track data as GPX, handling meridian crossing."""
|
93
146
|
gpx = '<?xml version="1.0" encoding="UTF-8"?>\n'
|
94
147
|
gpx += '<gpx version="1.1" creator="Windborne" xmlns="http://www.topografix.com/GPX/1/1">\n'
|
95
148
|
|
96
|
-
for cyclone_id, tracks in
|
149
|
+
for cyclone_id, tracks in track_data.items():
|
97
150
|
gpx += f' <trk>\n <name>{cyclone_id}</name>\n'
|
98
151
|
|
99
152
|
current_segment = []
|
@@ -101,7 +154,6 @@ def save_track_as_gpx(filename, cyclone_data):
|
|
101
154
|
|
102
155
|
for i in range(len(tracks)):
|
103
156
|
lon = float(tracks[i]['longitude'])
|
104
|
-
lat = float(tracks[i]['latitude'])
|
105
157
|
|
106
158
|
if not current_segment:
|
107
159
|
current_segment.append(tracks[i])
|
@@ -130,7 +182,7 @@ def save_track_as_gpx(filename, cyclone_data):
|
|
130
182
|
gpx += ' <trkseg>\n'
|
131
183
|
for point in current_segment:
|
132
184
|
gpx += f' <trkpt lat="{point["latitude"]}" lon="{point["longitude"]}">\n'
|
133
|
-
gpx += f' <time>{point[
|
185
|
+
gpx += f' <time>{point[time_key]}</time>\n'
|
134
186
|
gpx += ' </trkpt>\n'
|
135
187
|
gpx += ' </trkseg>\n'
|
136
188
|
|
@@ -143,10 +195,10 @@ def save_track_as_gpx(filename, cyclone_data):
|
|
143
195
|
print(f"Saved to {filename}")
|
144
196
|
|
145
197
|
|
146
|
-
def save_track_as_geojson(filename,
|
147
|
-
"""Convert and save
|
198
|
+
def save_track_as_geojson(filename, track_data, time_key='time'):
|
199
|
+
"""Convert and save track data as GeoJSON, handling meridian crossing."""
|
148
200
|
features = []
|
149
|
-
for
|
201
|
+
for id, tracks in track_data.items():
|
150
202
|
# Initialize lists to store line segments
|
151
203
|
line_segments = []
|
152
204
|
current_segment = []
|
@@ -188,9 +240,9 @@ def save_track_as_geojson(filename, cyclone_data):
|
|
188
240
|
feature = {
|
189
241
|
"type": "Feature",
|
190
242
|
"properties": {
|
191
|
-
"
|
192
|
-
"start_time": tracks[0][
|
193
|
-
"end_time": tracks[-1][
|
243
|
+
"id": id,
|
244
|
+
"start_time": tracks[0][time_key],
|
245
|
+
"end_time": tracks[-1][time_key]
|
194
246
|
},
|
195
247
|
"geometry": {
|
196
248
|
"type": "MultiLineString",
|
windborne/utils.py
CHANGED
@@ -46,7 +46,7 @@ def to_unix_timestamp(date_string):
|
|
46
46
|
|
47
47
|
# Supported date format
|
48
48
|
# Compact format YYYYMMDDHH
|
49
|
-
def parse_time(time, init_time_flag=None):
|
49
|
+
def parse_time(time, init_time_flag=None, require_past=False):
|
50
50
|
"""
|
51
51
|
Parse and validate initialization time with support for multiple formats.
|
52
52
|
Returns validated initialization time in ISO format, or None if invalid.
|
@@ -78,10 +78,9 @@ def parse_time(time, init_time_flag=None):
|
|
78
78
|
print(" - Initialization time hour must be 00, 06, 12, or 18")
|
79
79
|
exit(2)
|
80
80
|
|
81
|
-
if parsed_date > datetime.now():
|
82
|
-
print(f"
|
83
|
-
|
84
|
-
exit(1111)
|
81
|
+
if require_past and parsed_date > datetime.now():
|
82
|
+
print(f"Invalid date: {time} -- cannot be in the future")
|
83
|
+
exit(2)
|
85
84
|
|
86
85
|
return parsed_date.strftime('%Y-%m-%dT%H:00:00')
|
87
86
|
|
@@ -158,3 +157,33 @@ def save_arbitrary_response(output_file, response, csv_data_key=None):
|
|
158
157
|
else:
|
159
158
|
print("Unsupported file format. Please use either .json or .csv.")
|
160
159
|
exit(4)
|
160
|
+
|
161
|
+
|
162
|
+
def print_table(data, keys=None, headers=None):
|
163
|
+
if len(data) == 0:
|
164
|
+
print("No data found")
|
165
|
+
return
|
166
|
+
|
167
|
+
if keys is None:
|
168
|
+
keys = list(data[0].keys())
|
169
|
+
|
170
|
+
if headers is None:
|
171
|
+
headers = keys
|
172
|
+
|
173
|
+
# headers = ["Index", "Mission ID", "Mission Name"]
|
174
|
+
rows = [
|
175
|
+
[
|
176
|
+
str(value.get(key)) if key != 'i' else str(i)
|
177
|
+
for key in keys
|
178
|
+
]
|
179
|
+
for i, value in enumerate(data, start=1)
|
180
|
+
]
|
181
|
+
|
182
|
+
# Calculate column widths
|
183
|
+
col_widths = [max(len(cell) for cell in col) + 2 for col in zip(headers, *rows)]
|
184
|
+
|
185
|
+
# Display table
|
186
|
+
print("".join(f"{headers[i]:<{col_widths[i]}}" for i in range(len(headers))))
|
187
|
+
print("".join("-" * col_width for col_width in col_widths))
|
188
|
+
for row in rows:
|
189
|
+
print("".join(f"{row[i]:<{col_widths[i]}}" for i in range(len(row))))
|
@@ -0,0 +1,13 @@
|
|
1
|
+
windborne/__init__.py,sha256=DcxmiLq4UnjoRjZd4UeuYt29FBsi9paMXGtI91Itn04,1826
|
2
|
+
windborne/api_request.py,sha256=zh1TaaZAaRfAXp2NYMja75fKeduWLfao02JRRFVpQCA,11108
|
3
|
+
windborne/cli.py,sha256=xRPRLh50iC4Uu0AYEHL-LHRxUnpxqB_Mn-easp5VSK4,35370
|
4
|
+
windborne/data_api.py,sha256=QsG1NJyOCzTrTLRaoz5Z4nGRI0HCMEJvbDyRUlY3Usg,31388
|
5
|
+
windborne/forecasts_api.py,sha256=E0X9FyUW2yz3pQZKkFBYDMeg6PhFJ1bp4z1Cg1g0uGs,13987
|
6
|
+
windborne/observation_formatting.py,sha256=c739aaun6aaYhXl5VI-SRGR-TDS355_0Bfu1t6McoiM,14993
|
7
|
+
windborne/track_formatting.py,sha256=LaLfTyjpWoOtHmReJPLViY0MKm_iPL_5I2OB_lNvGGA,10054
|
8
|
+
windborne/utils.py,sha256=H8gvZ4Lrr0UmLl25iMZs6NsZliCY_73Ved_rBIqxJg4,7240
|
9
|
+
windborne-1.1.3.dist-info/METADATA,sha256=unPmA3uPzt8obgqSmJSrsSzSLat4yH7o3uJtb-i3wy0,1235
|
10
|
+
windborne-1.1.3.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
|
11
|
+
windborne-1.1.3.dist-info/entry_points.txt,sha256=j_YrqdCDrCd7p5MIwQ2BYwNXEi95VNANzLRJmcXEg1U,49
|
12
|
+
windborne-1.1.3.dist-info/top_level.txt,sha256=PE9Lauriu5S5REf7JKhXprufZ_V5RiZ_TnfnrLGJrmE,10
|
13
|
+
windborne-1.1.3.dist-info/RECORD,,
|
windborne-1.1.1.dist-info/RECORD
DELETED
@@ -1,13 +0,0 @@
|
|
1
|
-
windborne/__init__.py,sha256=0bPtPzBG3djZMVfyUNhapiEqCSyN8SSDsm_eCZ4kwhc,1783
|
2
|
-
windborne/api_request.py,sha256=J7kcCC3xiISHnrTLxMkFQCEu8rg8UntYvFfP5wkLabQ,11117
|
3
|
-
windborne/cli.py,sha256=hAnG1n6ZDICqj3nvjS8Xkc3psLeL-Hxewkmy-FABSCU,35807
|
4
|
-
windborne/cyclone_formatting.py,sha256=0S8S_PflRGm6ftUlyR2aGGyX6IRn0hbUTEWptu7f8Q8,7886
|
5
|
-
windborne/data_api.py,sha256=JBdedKWdx3eXKz0wGKqDLkR-QYl8O2SjutPL3LPx4l8,30457
|
6
|
-
windborne/forecasts_api.py,sha256=-IM78Dr10rV-CKVqRWXpptJPe7Hh64DNMO3P3zVgvuQ,13790
|
7
|
-
windborne/observation_formatting.py,sha256=c739aaun6aaYhXl5VI-SRGR-TDS355_0Bfu1t6McoiM,14993
|
8
|
-
windborne/utils.py,sha256=AJhvdwtfDrV0NQyN_I5JxXr6K3UgFlPCwUGqApe-c2E,6431
|
9
|
-
windborne-1.1.1.dist-info/METADATA,sha256=W5ZRFOnDBarbmqo8Wr4Ix8NWC47VqpsYaQYLVo3W5Vk,1235
|
10
|
-
windborne-1.1.1.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
|
11
|
-
windborne-1.1.1.dist-info/entry_points.txt,sha256=j_YrqdCDrCd7p5MIwQ2BYwNXEi95VNANzLRJmcXEg1U,49
|
12
|
-
windborne-1.1.1.dist-info/top_level.txt,sha256=PE9Lauriu5S5REf7JKhXprufZ_V5RiZ_TnfnrLGJrmE,10
|
13
|
-
windborne-1.1.1.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|