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 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
- return None
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
- cyclones_parser = subparsers.add_parser('cyclones', help='Get tropical cyclone forecasts')
214
- cyclones_parser.add_argument('-b', '--basin', help='Optional: filter tropical cyclones on basin[ NA, EP, WP, NI, SI, AU, SP]')
215
- cyclones_parser.add_argument('args', nargs='*',
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
- if get_initialization_times():
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 == 'cyclones':
605
+ elif args.command == 'tropical_cyclones':
593
606
  # Parse cyclones arguments
594
- basin_name = 'ALL basins'
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
- print("Loading tropical cyclones for our latest available initialization time\n")
601
- if get_tropical_cyclones(basin=args.basin):
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
- if get_tropical_cyclones(initialization_time=args.args[0], basin=args.basin):
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 cyclones [initialization_time] output_file")
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
- def get_predicted_path(mission_id=None, output_file=None):
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
- If provided, saves the data in CSV format.
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
- flying_missions_response = get_flying_missions()
639
- flying_missions = flying_missions_response.get("missions", [])
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 mission_id not in [mission.get("id") for mission in flying_missions]:
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
- # Define headers and data
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/{mission_id}/prediction.json"
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
- save_arbitrary_response(output_file, response, csv_data_key='prediction')
673
-
674
- return response.get('prediction')
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')
@@ -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
- make_api_request
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
- print("Generating point forecast...")
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 '.' not in output_file:
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 hours again.')
264
- elif output_file.lower().endswith('.csv'):
265
- # Flatten for CSV
266
- flattened_data = []
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
- for track in tracks:
269
- track_data = {
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, timezone
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 save_track_as_little_r(filename, cyclone_data):
6
+ def save_track(output_file, track_data, time_key='time', require_ids=False):
5
7
  """
6
- Convert and save cyclone data in little_R format.
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
- for cyclone_id, tracks in cyclone_data.items():
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['time'].replace('Z', '+00:00'))
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, cyclone_data):
92
+ def save_track_as_kml(filename, track_data):
39
93
  """
40
- Convert and save cyclone data as KML, handling meridian crossing.
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 cyclone_id, tracks in cyclone_data.items():
46
- kml += f' <Placemark>\n <name>{cyclone_id}</name>\n <MultiGeometry>\n'
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, cyclone_data):
92
- """Convert and save cyclone data as GPX, handling meridian crossing."""
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 cyclone_data.items():
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["time"]}</time>\n'
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, cyclone_data):
147
- """Convert and save cyclone data as GeoJSON, handling meridian crossing."""
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 cyclone_id, tracks in cyclone_data.items():
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
- "cyclone_id": cyclone_id,
192
- "start_time": tracks[0]['time'],
193
- "end_time": tracks[-1]['time']
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"How would it be to live in {parsed_date} ?\n")
83
- print("Looks like you are coming from the future!\n")
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))))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: windborne
3
- Version: 1.1.1
3
+ Version: 1.1.3
4
4
  Summary: A Python library for interacting with WindBorne Data and Forecasts API
5
5
  Author-email: WindBorne Systems <data@windbornesystems.com>
6
6
  Classifier: Programming Language :: Python :: 3
@@ -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,,
@@ -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,,