windborne 1.0.9__py3-none-any.whl → 1.1.0__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 +6 -15
- windborne/api_request.py +227 -0
- windborne/cli.py +52 -60
- windborne/cyclone_formatting.py +210 -0
- windborne/data_api.py +390 -1028
- windborne/forecasts_api.py +186 -305
- windborne/observation_formatting.py +456 -0
- windborne/utils.py +15 -887
- {windborne-1.0.9.dist-info → windborne-1.1.0.dist-info}/METADATA +1 -2
- windborne-1.1.0.dist-info/RECORD +13 -0
- windborne/config.py +0 -42
- windborne-1.0.9.dist-info/RECORD +0 -11
- {windborne-1.0.9.dist-info → windborne-1.1.0.dist-info}/WHEEL +0 -0
- {windborne-1.0.9.dist-info → windborne-1.1.0.dist-info}/entry_points.txt +0 -0
- {windborne-1.0.9.dist-info → windborne-1.1.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,456 @@
|
|
1
|
+
import os
|
2
|
+
import json
|
3
|
+
import csv
|
4
|
+
from datetime import datetime, timezone
|
5
|
+
|
6
|
+
|
7
|
+
def format_little_r_value(value, fortran_format, align=None):
|
8
|
+
"""
|
9
|
+
Format a value according to a given Fortran format for use in little_r
|
10
|
+
"""
|
11
|
+
if fortran_format[0] == 'F':
|
12
|
+
length, decimal_places = fortran_format[1:].split('.')
|
13
|
+
if value is None or value == '':
|
14
|
+
return ' ' * int(length)
|
15
|
+
|
16
|
+
# turn into a string of length characters, with decimal_places decimal places
|
17
|
+
return f"{value:>{length}.{decimal_places}f}"[:int(length)]
|
18
|
+
|
19
|
+
if fortran_format[0] == 'I':
|
20
|
+
length = int(fortran_format[1:])
|
21
|
+
if value is None or value == '':
|
22
|
+
return ' ' * length
|
23
|
+
|
24
|
+
return f"{value:>{length}d}"[:int(length)]
|
25
|
+
|
26
|
+
if fortran_format[0] == 'A':
|
27
|
+
length = int(fortran_format[1:])
|
28
|
+
if value is None:
|
29
|
+
return ' ' * length
|
30
|
+
|
31
|
+
if align == 'right':
|
32
|
+
return str(value)[:length].rjust(length, ' ')
|
33
|
+
|
34
|
+
return str(value)[:length].ljust(length, ' ')
|
35
|
+
|
36
|
+
if fortran_format[0] == 'L':
|
37
|
+
if value and value in ['T', 't', 'True', 'true', '1', True]:
|
38
|
+
value = 'T'
|
39
|
+
else:
|
40
|
+
value = 'F'
|
41
|
+
|
42
|
+
length = int(fortran_format[1:])
|
43
|
+
|
44
|
+
return value.rjust(length, ' ')
|
45
|
+
|
46
|
+
raise ValueError(f"Unknown format: {fortran_format}")
|
47
|
+
|
48
|
+
|
49
|
+
def safe_little_r_float(value, default=-888888.0):
|
50
|
+
"""
|
51
|
+
Convert a value to float. If the value is None, empty, or invalid, return the default.
|
52
|
+
"""
|
53
|
+
try:
|
54
|
+
return float(value) if value not in (None, '', 'None') else default
|
55
|
+
except (ValueError, TypeError):
|
56
|
+
return default
|
57
|
+
|
58
|
+
|
59
|
+
def format_little_r(observations):
|
60
|
+
"""
|
61
|
+
Convert observations to Little_R format.
|
62
|
+
|
63
|
+
Args:
|
64
|
+
observations (list): List of observation dictionaries
|
65
|
+
|
66
|
+
Returns:
|
67
|
+
list: Formatted Little_R records
|
68
|
+
"""
|
69
|
+
little_r_records = []
|
70
|
+
|
71
|
+
for point in observations:
|
72
|
+
# Observation time
|
73
|
+
observation_time = datetime.fromtimestamp(point['timestamp'], tz=timezone.utc)
|
74
|
+
|
75
|
+
# Convert and validate fields
|
76
|
+
pressure_hpa = safe_little_r_float(point.get('pressure'))
|
77
|
+
pressure_pa = pressure_hpa * 100.0
|
78
|
+
|
79
|
+
temperature_c = safe_little_r_float(point.get('temperature'))
|
80
|
+
temperature_k = temperature_c + 273.15
|
81
|
+
|
82
|
+
altitude = safe_little_r_float(point.get('altitude'))
|
83
|
+
humidity = safe_little_r_float(point.get('humidity'))
|
84
|
+
speed_u = safe_little_r_float(point.get('speed_u'))
|
85
|
+
speed_v = safe_little_r_float(point.get('speed_v'))
|
86
|
+
|
87
|
+
# Header formatting
|
88
|
+
header = ''.join([
|
89
|
+
# Latitude: F20.5
|
90
|
+
format_little_r_value(point.get('latitude'), 'F20.5'),
|
91
|
+
|
92
|
+
# Longitude: F20.5
|
93
|
+
format_little_r_value(point.get('longitude'), 'F20.5'),
|
94
|
+
|
95
|
+
# ID: A40
|
96
|
+
format_little_r_value(point.get('id'), 'A40'),
|
97
|
+
|
98
|
+
# Name: A40
|
99
|
+
format_little_r_value(point.get('mission_name'), 'A40'),
|
100
|
+
|
101
|
+
# Platform (FM‑Code): A40
|
102
|
+
format_little_r_value('FM-35 TEMP', 'A40'),
|
103
|
+
|
104
|
+
# Source: A40
|
105
|
+
format_little_r_value('WindBorne', 'A40'),
|
106
|
+
|
107
|
+
# Elevation: F20.5
|
108
|
+
format_little_r_value('', 'F20.5'),
|
109
|
+
|
110
|
+
# Valid fields: I10
|
111
|
+
format_little_r_value(-888888, 'I10'),
|
112
|
+
|
113
|
+
# Num. errors: I10
|
114
|
+
format_little_r_value(0, 'I10'),
|
115
|
+
|
116
|
+
# Num. warnings: I10
|
117
|
+
format_little_r_value(0, 'I10'),
|
118
|
+
|
119
|
+
# Sequence number: I10
|
120
|
+
format_little_r_value(0, 'I10'),
|
121
|
+
|
122
|
+
# Num. duplicates: I10
|
123
|
+
format_little_r_value(0, 'I10'),
|
124
|
+
|
125
|
+
# Is sounding?: L
|
126
|
+
format_little_r_value('T', 'L10'),
|
127
|
+
|
128
|
+
# Is bogus?: L
|
129
|
+
format_little_r_value('F', 'L10'),
|
130
|
+
|
131
|
+
# Discard?: L
|
132
|
+
format_little_r_value('F', 'L10'),
|
133
|
+
|
134
|
+
# Unix time: I10
|
135
|
+
# format_value(point['timestamp'], 'I10'),
|
136
|
+
format_little_r_value(-888888, 'I10'),
|
137
|
+
|
138
|
+
# Julian day: I10
|
139
|
+
format_little_r_value(-888888, 'I10'),
|
140
|
+
|
141
|
+
# Date: A20 YYYYMMDDhhmmss
|
142
|
+
format_little_r_value(observation_time.strftime('%Y%m%d%H%M%S'), 'A20', align='right'),
|
143
|
+
|
144
|
+
# SLP, QC: F13.5, I7
|
145
|
+
format_little_r_value(-888888.0, 'F13.5') + format_little_r_value(0, 'I7'),
|
146
|
+
|
147
|
+
# Ref Pressure, QC: F13.5, I7
|
148
|
+
format_little_r_value(-888888.0, 'F13.5') + format_little_r_value(0, 'I7'),
|
149
|
+
|
150
|
+
# Ground Temp, QC: F13.5, I7
|
151
|
+
format_little_r_value(-888888.0, 'F13.5') + format_little_r_value(0, 'I7'),
|
152
|
+
|
153
|
+
# SST, QC: F13.5, I7
|
154
|
+
format_little_r_value(-888888.0, 'F13.5') + format_little_r_value(0, 'I7'),
|
155
|
+
|
156
|
+
# SFC Pressure, QC: F13.5, I7
|
157
|
+
format_little_r_value(-888888.0, 'F13.5') + format_little_r_value(0, 'I7'),
|
158
|
+
|
159
|
+
# Precip, QC: F13.5, I7
|
160
|
+
format_little_r_value(-888888.0, 'F13.5') + format_little_r_value(0, 'I7'),
|
161
|
+
|
162
|
+
# Daily Max T, QC: F13.5, I7
|
163
|
+
format_little_r_value(-888888.0, 'F13.5') + format_little_r_value(0, 'I7'),
|
164
|
+
|
165
|
+
# Daily Min T, QC: F13.5, I7
|
166
|
+
format_little_r_value(-888888.0, 'F13.5') + format_little_r_value(0, 'I7'),
|
167
|
+
|
168
|
+
# Night Min T, QC: F13.5, I7
|
169
|
+
format_little_r_value(-888888.0, 'F13.5') + format_little_r_value(0, 'I7'),
|
170
|
+
|
171
|
+
# 3hr Pres Change, QC: F13.5, I7
|
172
|
+
format_little_r_value(-888888.0, 'F13.5') + format_little_r_value(0, 'I7'),
|
173
|
+
|
174
|
+
# 24hr Pres Change, QC: F13.5, I7
|
175
|
+
format_little_r_value(-888888.0, 'F13.5') + format_little_r_value(0, 'I7'),
|
176
|
+
|
177
|
+
# Cloud cover, QC: F13.5, I7
|
178
|
+
format_little_r_value(-888888.0, 'F13.5') + format_little_r_value(0, 'I7'),
|
179
|
+
|
180
|
+
# Ceiling, QC: F13.5, I7
|
181
|
+
format_little_r_value(-888888.0, 'F13.5') + format_little_r_value(0, 'I7'),
|
182
|
+
|
183
|
+
# Precipitable water, QC (see note): F13.5, I7
|
184
|
+
format_little_r_value(-888888.0, 'F13.5') + format_little_r_value(0, 'I7'),
|
185
|
+
])
|
186
|
+
|
187
|
+
# Data record formatting
|
188
|
+
data_record = ''.join([
|
189
|
+
# Pressure (Pa): F13.5
|
190
|
+
format_little_r_value(pressure_pa, 'F13.5'),
|
191
|
+
|
192
|
+
# QC: I7
|
193
|
+
format_little_r_value(0, 'I7'),
|
194
|
+
|
195
|
+
# Height (m): F13.5
|
196
|
+
format_little_r_value(altitude, 'F13.5'),
|
197
|
+
|
198
|
+
# QC: I7
|
199
|
+
format_little_r_value(0, 'I7'),
|
200
|
+
|
201
|
+
# Temperature (K): F13.5
|
202
|
+
format_little_r_value(temperature_k, 'F13.5'),
|
203
|
+
|
204
|
+
# QC: I7
|
205
|
+
format_little_r_value(0, 'I7'),
|
206
|
+
|
207
|
+
# Dew point (K): F13.5
|
208
|
+
format_little_r_value(-888888.0, 'F13.5'),
|
209
|
+
|
210
|
+
# QC: I7
|
211
|
+
format_little_r_value(0, 'I7'),
|
212
|
+
|
213
|
+
# Wind speed (m/s): F13.5
|
214
|
+
format_little_r_value(-888888.0, 'F13.5'),
|
215
|
+
|
216
|
+
# QC: I7
|
217
|
+
format_little_r_value(0, 'I7'),
|
218
|
+
|
219
|
+
# Wind direction (deg): F13.5
|
220
|
+
format_little_r_value(-888888.0, 'F13.5'),
|
221
|
+
|
222
|
+
# QC: I7
|
223
|
+
format_little_r_value(0, 'I7'),
|
224
|
+
|
225
|
+
# Wind U (m/s): F13.5
|
226
|
+
format_little_r_value(speed_u, 'F13.5'),
|
227
|
+
|
228
|
+
# QC: I7
|
229
|
+
format_little_r_value(0, 'I7'),
|
230
|
+
|
231
|
+
# Wind V (m/s): F13.5
|
232
|
+
format_little_r_value(speed_v, 'F13.5'),
|
233
|
+
|
234
|
+
# QC: I7
|
235
|
+
format_little_r_value(0, 'I7'),
|
236
|
+
|
237
|
+
# Relative humidity (%): F13.5
|
238
|
+
format_little_r_value(humidity, 'F13.5'),
|
239
|
+
|
240
|
+
# QC: I7
|
241
|
+
format_little_r_value(0, 'I7'),
|
242
|
+
|
243
|
+
# Thickness (m): F13.5
|
244
|
+
format_little_r_value(-888888.0, 'F13.5'),
|
245
|
+
|
246
|
+
# QC: I7
|
247
|
+
format_little_r_value(0, 'I7')
|
248
|
+
])
|
249
|
+
|
250
|
+
# End record and tail record
|
251
|
+
end_record = '-777777.00000 0-777777.00000 0-888888.00000 0-888888.00000 0-888888.00000 0-888888.00000 0-888888.00000 0-888888.00000 0-888888.00000 0-888888.00000 0'
|
252
|
+
tail_record = ' 39 0 0'
|
253
|
+
|
254
|
+
# Combine into a complete record
|
255
|
+
complete_record = '\n'.join([header, data_record, end_record, tail_record, ''])
|
256
|
+
little_r_records.append(complete_record)
|
257
|
+
|
258
|
+
return little_r_records
|
259
|
+
|
260
|
+
def convert_to_netcdf(data, curtime, output_filename):
|
261
|
+
"""
|
262
|
+
Convert data to netCDF format for WMO ISARRA program.
|
263
|
+
|
264
|
+
The output format is netCDF and the style (variable names, file names, etc.) are described here:
|
265
|
+
https://github.com/synoptic/wmo-uasdc/tree/main/raw_uas_to_netCDF
|
266
|
+
"""
|
267
|
+
|
268
|
+
# Import necessary libraries
|
269
|
+
try:
|
270
|
+
import xarray as xr
|
271
|
+
except ImportError:
|
272
|
+
print("Please install the xarray library to save as netCDF, eg 'python3 -m pip install xarray'.")
|
273
|
+
return
|
274
|
+
|
275
|
+
try:
|
276
|
+
import pandas as pd
|
277
|
+
except ImportError:
|
278
|
+
print("Please install the pandas library to save as netCDF, eg 'python3 -m pip install pandas'.")
|
279
|
+
return
|
280
|
+
|
281
|
+
try:
|
282
|
+
import numpy as np
|
283
|
+
except ImportError:
|
284
|
+
print("Please install the numpy library to save as netCDF, eg 'python3 -m pip install numpy'.")
|
285
|
+
return
|
286
|
+
|
287
|
+
# Mapping of WindBorne names to ISARRA names
|
288
|
+
rename_dict = {
|
289
|
+
'latitude': 'lat',
|
290
|
+
'longitude': 'lon',
|
291
|
+
'altitude': 'altitude',
|
292
|
+
'temperature': 'air_temperature',
|
293
|
+
'wind_direction': 'wind_direction',
|
294
|
+
'wind_speed': 'wind_speed',
|
295
|
+
'pressure': 'air_pressure',
|
296
|
+
'humidity_mixing_ratio': 'humidity_mixing_ratio',
|
297
|
+
'index': 'obs',
|
298
|
+
}
|
299
|
+
|
300
|
+
# Convert dictionary to list for DataFrame
|
301
|
+
data_list = []
|
302
|
+
if isinstance(data, dict):
|
303
|
+
# If input is dictionary, convert to list
|
304
|
+
for obs_id, obs_data in data.items():
|
305
|
+
clean_data = {k: None if v == 'None' else v for k, v in obs_data.items()}
|
306
|
+
data_list.append(clean_data)
|
307
|
+
else:
|
308
|
+
# If input is already a list
|
309
|
+
for obs_data in data:
|
310
|
+
clean_data = {k: None if v == 'None' else v for k, v in obs_data.items()}
|
311
|
+
data_list.append(clean_data)
|
312
|
+
|
313
|
+
# Put the data in a panda dataframe in order to easily push to xarray then netcdf output
|
314
|
+
df = pd.DataFrame(data_list)
|
315
|
+
|
316
|
+
# Convert numeric columns to float
|
317
|
+
numeric_columns = ['latitude', 'longitude', 'altitude', 'pressure', 'temperature',
|
318
|
+
'speed_u', 'speed_v', 'specific_humidity', 'timestamp']
|
319
|
+
for col in numeric_columns:
|
320
|
+
if col in df.columns:
|
321
|
+
df[col] = pd.to_numeric(df[col], errors='coerce')
|
322
|
+
|
323
|
+
ds = xr.Dataset.from_dataframe(df)
|
324
|
+
|
325
|
+
# Build the filename and save some variables for use later
|
326
|
+
mt = datetime.fromtimestamp(curtime, tz=timezone.utc)
|
327
|
+
|
328
|
+
# Handle dropsondes
|
329
|
+
mission_name = str(df['mission_name'].iloc[0]) if (not df.empty and not pd.isna(df['mission_name'].iloc[0])) else ' '
|
330
|
+
|
331
|
+
is_multi_mission = False
|
332
|
+
|
333
|
+
if len(df['mission_name'].unique()) > 1:
|
334
|
+
is_multi_mission = True
|
335
|
+
|
336
|
+
output_file = output_filename
|
337
|
+
|
338
|
+
# Derived quantities calculated here:
|
339
|
+
|
340
|
+
# convert from specific humidity to humidity_mixing_ratio
|
341
|
+
mg_to_kg = 1000000.
|
342
|
+
if not all(x is None for x in ds['specific_humidity'].data):
|
343
|
+
ds['humidity_mixing_ratio'] = (ds['specific_humidity'] / mg_to_kg) / (1 - (ds['specific_humidity'] / mg_to_kg))
|
344
|
+
else:
|
345
|
+
ds['humidity_mixing_ratio'] = ds['specific_humidity']
|
346
|
+
|
347
|
+
# Wind speed and direction from components
|
348
|
+
ds['wind_speed'] = np.sqrt(ds['speed_u']*ds['speed_u'] + ds['speed_v']*ds['speed_v'])
|
349
|
+
ds['wind_direction'] = np.mod(180 + (180 / np.pi) * np.arctan2(ds['speed_u'], ds['speed_v']), 360)
|
350
|
+
|
351
|
+
ds['time'] = ds['timestamp'].astype(float)
|
352
|
+
ds = ds.assign_coords(time=("time", ds['time'].data))
|
353
|
+
|
354
|
+
# Now that calculations are done, remove variables not needed in the netcdf output
|
355
|
+
variables_to_drop = ['humidity', 'speed_x', 'speed_y', 'timestamp']
|
356
|
+
if 'id' in ds and pd.isna(ds['id']).all():
|
357
|
+
variables_to_drop.append('id')
|
358
|
+
|
359
|
+
existing_vars = [var for var in variables_to_drop if var in ds]
|
360
|
+
ds = ds.drop_vars(existing_vars)
|
361
|
+
|
362
|
+
# Rename the variables
|
363
|
+
ds = ds.rename(rename_dict)
|
364
|
+
|
365
|
+
# Adding attributes to variables in the xarray dataset
|
366
|
+
ds['time'].attrs = {
|
367
|
+
'units': 'seconds since 1970-01-01T00:00:00',
|
368
|
+
'long_name': 'Time', '_FillValue': float('nan'),
|
369
|
+
'processing_level': ''
|
370
|
+
}
|
371
|
+
|
372
|
+
ds['lat'].attrs = {
|
373
|
+
'units': 'degrees_north',
|
374
|
+
'long_name': 'Latitude',
|
375
|
+
'_FillValue': float('nan'),
|
376
|
+
'processing_level': ''
|
377
|
+
}
|
378
|
+
ds['lon'].attrs = {
|
379
|
+
'units': 'degrees_east',
|
380
|
+
'long_name': 'Longitude',
|
381
|
+
'_FillValue': float('nan'),
|
382
|
+
'processing_level': ''
|
383
|
+
}
|
384
|
+
ds['altitude'].attrs = {
|
385
|
+
'units': 'meters_above_sea_level',
|
386
|
+
'long_name': 'Altitude',
|
387
|
+
'_FillValue': float('nan'),
|
388
|
+
'processing_level': ''
|
389
|
+
}
|
390
|
+
ds['air_temperature'].attrs = {
|
391
|
+
'units': 'Kelvin',
|
392
|
+
'long_name': 'Air Temperature',
|
393
|
+
'_FillValue': float('nan'),
|
394
|
+
'processing_level': ''
|
395
|
+
}
|
396
|
+
ds['wind_speed'].attrs = {
|
397
|
+
'units': 'm/s',
|
398
|
+
'long_name': 'Wind Speed',
|
399
|
+
'_FillValue': float('nan'),
|
400
|
+
'processing_level': ''
|
401
|
+
}
|
402
|
+
ds['wind_direction'].attrs = {
|
403
|
+
'units': 'degrees',
|
404
|
+
'long_name': 'Wind Direction',
|
405
|
+
'_FillValue': float('nan'),
|
406
|
+
'processing_level': ''
|
407
|
+
}
|
408
|
+
ds['humidity_mixing_ratio'].attrs = {
|
409
|
+
'units': 'kg/kg',
|
410
|
+
'long_name': 'Humidity Mixing Ratio',
|
411
|
+
'_FillValue': float('nan'),
|
412
|
+
'processing_level': ''
|
413
|
+
}
|
414
|
+
ds['air_pressure'].attrs = {
|
415
|
+
'units': 'Pa',
|
416
|
+
'long_name': 'Atmospheric Pressure',
|
417
|
+
'_FillValue': float('nan'),
|
418
|
+
'processing_level': ''
|
419
|
+
}
|
420
|
+
ds['speed_u'].attrs = {
|
421
|
+
'units': 'm/s',
|
422
|
+
'long_name': 'Wind speed in direction of increasing longitude',
|
423
|
+
'_FillValue': float('nan'),
|
424
|
+
'processing_level': ''
|
425
|
+
}
|
426
|
+
ds['speed_v'].attrs = {
|
427
|
+
'units': 'm/s',
|
428
|
+
'long_name': 'Wind speed in direction of increasing latitude',
|
429
|
+
'_FillValue': float('nan'),
|
430
|
+
'processing_level': ''
|
431
|
+
}
|
432
|
+
ds['specific_humidity'].attrs = {
|
433
|
+
'units': 'mg/kg',
|
434
|
+
'long_name': 'Specific Humidity',
|
435
|
+
'_FillValue': float('nan'),
|
436
|
+
'processing_level': '',
|
437
|
+
'Conventions': "CF-1.8, WMO-CF-1.0"
|
438
|
+
}
|
439
|
+
ds['mission_name'].attrs = {
|
440
|
+
'long_name': 'Mission name',
|
441
|
+
'description': 'Which balloon collected the data'
|
442
|
+
}
|
443
|
+
|
444
|
+
# Add Global Attributes synonymous across all UASDC providers
|
445
|
+
if not is_multi_mission:
|
446
|
+
ds.attrs['wmo__cf_profile'] = "FM 303-2024"
|
447
|
+
ds.attrs['featureType'] = "trajectory"
|
448
|
+
|
449
|
+
# Add Global Attributes unique to Provider
|
450
|
+
ds.attrs['platform_name'] = "WindBorne Global Sounding Balloon"
|
451
|
+
if not is_multi_mission:
|
452
|
+
ds.attrs['flight_id'] = mission_name
|
453
|
+
|
454
|
+
ds.attrs['site_terrain_elevation_height'] = 'not applicable'
|
455
|
+
ds.attrs['processing_level'] = "b1"
|
456
|
+
ds.to_netcdf(output_file)
|